# = Introduction
# MP4Info supports the reading of tags and file info from MP4 audio files.
# It is based on the Perl module MP4::Info (http://search.cpan.org/~jhar/MP4-Info/)
# Note: MP4Info does not currently support Unicode strings.
#
# = License
# Copyright (C) 2006 Jason Terk <rain@xidus.net>
# 
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
#
# See the README file for usage information.

class MP4Info
  # Initialize a new MP4Info object from an IO object
  def initialize(io_stream)
    # Tag atoms
    @data_atoms = {
      "AART" => nil, "ALB" => nil, "ART" => nil, "CMT" => nil,
      "COVR" => nil, "CPIL" => nil, "CPRT" => nil, "DAY" => nil,
      "DISK" => nil, "GEN" => nil, "GNRE" => nil, "GRP" => nil,
      "NAM" => nil, "RTNG" => nil, "TMPO" => nil, "TOO" => nil,
      "TRKN" => nil, "WRT" => nil, "APID" => nil, "AKID" => nil,
      "ATID" => nil, "CNID" => nil, "GEID" => nil, "PLID" => nil,
      "TITL" => nil, "DSCP" => nil, "PERF" => nil, "AUTH" => nil
    }
    
    # Info atoms
    @info_atoms = {
      "VERSION" => nil, "BITRATE" => nil, "FREQUENCY" => nil, "MS" => nil,
      "SIZE" => nil, "SECS" => nil, "MM" => nil, "SS" => nil, "ENCRYPTED" => nil,
      "TIME" => nil, "COPYRIGHT" => nil, "LAYER" => nil
    }

    # Atoms that contain other atoms
    @container_atoms = {
      "ILST" => nil, "MDIA" => nil, "MINF" => nil, "MOOV" => nil,
      "STBL" => nil, "TRAK" => nil, "UDTA" => nil
    }
    
    # Non standard data atoms
    @other_atoms = {
      "MDAT" => :parse_mdat, "META" => :parse_meta,
      "MVHD" => :parse_mvhd, "STSD" => :parse_stsd
    }
    
    # Info/Tag aliases
    @aliases = {}
    
    # Just in case
    io_stream.binmode
    
    # Sanity check
    head = read_or_raise(io_stream, 8, "#{io_stream} does not appear to be an IO stream")
    raise "#{io_stream} does not appear to be an IO stream" unless head[4..7].downcase == "ftyp"
    
    # Back to the beginning
    io_stream.rewind
    
    parse_container io_stream, 0, io_stream.stat.size
    
    @info_atoms["VERSION"] = 4
    @info_atoms["LAYER"] = 1 if @info_atoms["FREQUENCY"]
    if (@info_atoms["SIZE"] && @info_atoms["MS"])
      @info_atoms["BITRATE"] = ( 0.5 + @info_atoms["SIZE"] /
        ( ( @info_atoms["MM"] * 60 + @info_atoms["SS"] + (@info_atoms["MS"] * 1.0) / 1000 ) * 128 ) ).floor
    end
    @info_atoms["COPYRIGHT"] = true if @info_atoms["CPRT"]
  end

  # Get an MP4Info object from a file name
  def MP4Info.open(file_name)
    MP4Info.new(File.new(file_name))
  end
  
  # Dynamically get tags and info
  def method_missing(id)
    field = id.to_s
  
    if (@data_atoms.has_key?(field))
      @data_atoms[field]
    elsif (@info_atoms.has_key?(field))
      @info_atoms[field]
    elsif (@aliases.has_key?(field))
      @aliases[field]
    else
      nil
    end
  end
  
  private
    # Parse a container
    def parse_container(io_stream, level, size)
      level = level + 1
      cont_end = io_stream.pos + size
      
      while io_stream.pos < cont_end do
        parse_atom io_stream, level
      end
      
      if (io_stream.pos != cont_end)
        raise "Parse error"
      end
    end
    
    # Parse an atom
    def parse_atom(io_stream, level)
      head = read_or_raise(io_stream, 8, "Premature end of file")
      
      size, id = head.unpack("Na4")
      if (size == 1)
        # Extended size, whatever that means
        head = read_or_raise(io_stream, 8, "Premature end of file")
        hi, low = head.unpack("NN")
        size = hi * (2**32) + low - 16
      else
        size = size - 8
      end
      
      if (size <= 0)
        if (size == 0 and level ==1)
          return
        else
          raise "Parse error"
        end
      end
      
      re = /[^\w\-]/
      id.gsub!(re, "")
      id = id.upcase
      
      printf "%s%s: %d bytes\n", ' ' * ( 2 * level ), id, size if $DEBUG
      
      if (@data_atoms.has_key?(id))
        parse_data io_stream, level, size, id
      elsif (@other_atoms.has_key?(id))
        self.send(@other_atoms[id], io_stream, level, size)
      elsif (@container_atoms.has_key?(id))
        parse_container io_stream, level, size
      else
        # Unknown atom, move on
        io_stream.seek size, 1
      end
    end
    
    # Parse an MDAT atom
    #
    # Pre-conditions: size = size of atom contents
    #                 io_stream points to start of atom contents
    #
    # Post-condition: io_stream points past end of atom contents
    def parse_mdat(io_stream, level, size)
      @info_atoms["SIZE"] = 0 unless @info_atoms["SIZE"]
      @info_atoms["SIZE"] = @info_atoms["SIZE"] + size
      io_stream.seek(size, 1)
    end
    
    # Parse a META atom
    #
    # Pre-conditions: size = size of atom contents
    #                 io_stream points to start of atom contents
    #
    # Post-condition: io_stream points past end of atom contents
    def parse_meta(io_stream, level, size)
      # META is a container preceeded by a version field
      io_stream.seek(4, 1)
      parse_container(io_stream, level, size - 4)
    end
    
    # Parse an MVHD atom
    #
    # Pre-conditions: size = size of atom contents
    #                 io_stream points to start of atom contents
    #
    # Post-condition: io_stream points past end of atom contents
    def parse_mvhd(io_stream, level, size)
      raise "Parse error" if size < 32
      data = read_or_raise(io_stream, size, "Premature end of file")
      
      version = data.unpack("C")[0] & 255
      if (version == 0)
        scale, duration = data[12..19].unpack("NN")
      elsif (version == 1)
        scale, hi, low = data[20..31].unpack("NNN")
        duration = hi * (2**32) + low
      else
        return
      end
      
      printf "  %sDur/Scl=#{duration}/#{scale}\n", ' ' * ( 2 * level ) if $DEBUG
      
      secs                = (duration * 1.0) / scale
      @info_atoms["SECS"] = (secs).round
      @info_atoms["MM"]   = (secs / 60).floor
      @info_atoms["SS"]   = (secs - @info_atoms["MM"] * 60).floor
      @info_atoms["MS"]   = (1000 * (secs - secs.floor)).round
      @info_atoms["TIME"] = sprintf "%02d:%02d", @info_atoms["MM"],
                            @info_atoms["SECS"] - @info_atoms["MM"] * 60;
    end
    
    # Parse an STSD atom
    #
    # Pre-conditions: size = size of atom contents
    #                 io_stream points to start of atom contents
    #
    # Post-condition: io_stream points past end of atom contents
    def parse_stsd(io_stream, level, size)
      raise "Parse error" if size < 44
      data = read_or_raise(io_stream, size, "Premature end of headers")
      
      printf "  %sSample=%s\n", ' ' * ( 2 * level ), data[12..15] if $DEBUG
      
      data_format = data[12..15].downcase
      
      # Is this an audio track?
      if (data_format == "mp4a" || data_format == "drms" ||
          data_format == "samr" || data_format == "sawb" ||
          data_format == "sawp" || data_format == "enca")
        @info_atoms["FREQUENCY"] = (data[40..43].unpack("N")[0] * 1.0) / 65536000
        printf "  %sFreq=%s\n", ' ' * ( 2 * level ), @info_atoms["FREQUENCY"] if $DEBUG
      end
      
      if (data_format == "drms" || data_format[0..2] == "enc")
        @info_atoms["ENCRYPTED"] = true;
      end
    end
    
    def parse_data(io_stream, level, size, id)
      # Possible genres...
      mp4_genres = [
        'N/A',               'Blues',
        'Classic Rock',      'Country',
        'Dance',             'Disco',
        'Funk',              'Grunge',
        'Hip-Hop',           'Jazz',
        'Metal',             'New Age',
        'Oldies',            'Other',
        'Pop',               'R&B',
        'Rap',               'Reggae',
        'Rock',              'Techno',
        'Industrial',        'Alternative',
        'Ska',               'Death Metal',
        'Pranks',            'Soundtrack',
        'Euro-Techno',       'Ambient',
        'Trip-Hop',          'Vocal',
        'Jazz+Funk',         'Fusion',
        'Trance',            'Classical',
        'Instrumental',      'Acid',
        'House',             'Game',
        'Sound Clip',        'Gospel',
        'Noise',             'AlternRock',
        'Bass',              'Soul',
        'Punk',              'Space',
        'Meditative',        'Instrumental Pop',
        'Instrumental Rock', 'Ethnic',
        'Gothic',            'Darkwave',
        'Techno-Industrial', 'Electronic',
        'Pop-Folk',          'Eurodance',
        'Dream',             'Southern Rock',
        'Comedy',            'Cult',
        'Gangsta',           'Top 40',
        'Christian Rap',     'Pop/Funk',
        'Jungle',            'Native American',
        'Cabaret',           'New Wave',
        'Psychadelic',       'Rave',
        'Showtunes',         'Trailer',
        'Lo-Fi',             'Tribal',
        'Acid Punk',         'Acid Jazz',
        'Polka',             'Retro',
        'Musical',           'Rock & Roll',
        'Hard Rock',         'Folk',
        'Folk/Rock',         'National Folk',
        'Swing',             'Fast-Fusion',
        'Bebob',             'Latin',
        'Revival',           'Celtic',
        'Bluegrass',         'Avantgarde',
        'Gothic Rock',       'Progressive Rock',
        'Psychedelic Rock',  'Symphonic Rock',
        'Slow Rock',         'Big Band',
        'Chorus',            'Easy Listening',
        'Acoustic',          'Humour',
        'Speech',            'Chanson',
        'Opera',             'Chamber Music',
        'Sonata',            'Symphony',
        'Booty Bass',        'Primus',
        'Porn Groove',       'Satire',
        'Slow Jam',          'Club',
        'Tango',             'Samba',
        'Folklore',          'Ballad',
        'Power Ballad',      'Rhythmic Soul',
        'Freestyle',         'Duet',
        'Punk Rock',         'Drum Solo',
        'A capella',         'Euro-House',
        'Dance Hall',        'Goa',
        'Drum & Bass',       'Club House',
        'Hardcore',          'Terror',
        'Indie',             'BritPop',
        'NegerPunk',         'Polsk Punk',
        'Beat',              'Christian Gangsta',
        'Heavy Metal',       'Black Metal',
        'Crossover',         'Contemporary C',
        'Christian Rock',    'Merengue',
        'Salsa',             'Thrash Metal',
        'Anime',             'JPop',
        'SynthPop'
      ]
      
      data = read_or_raise(io_stream, size, "Premature end of file")
      
      if (id == "TITL" || id == "DSCP" || id == "CPRT" ||
          id == "PERF" || id == "AUTH" || id == "GNRE")
        ver = data.unpack("N")[0]
        if (ver == 0)
          return unless size > 7
          size = size - 7
          type = 1
          data = data[6..(6 + size - 1)]
          
          if (id == "TITL")
            return if @data_atoms["NAM"] != nil
            id = "NAM"
          elsif (id == "DSCP")
            return if @data_atoms["CMT"] != nil
            id = "CMT"
          elsif (id == "PERF")
            return if @data_atoms["ART"] != nil
            id = "ART"
          elsif (id == "AUTH")
            return if @data_atoms["WRT"] != nil
            id = "WRT"
          end
        end
      end
        
      if (type == nil)
        return unless size > 16
        size, atom, type = data.unpack("Na4N")
        
        return unless atom.downcase == "data" and size > 16
        
        size = size - 16
        type = type & 255
        data = data[16..(16 + size - 1)]
      end
      
      printf "  %sType=#{type}, Size=#{size}\n", ' ' * ( 2 * level ) if $DEBUG
      
      if (type == 0)
        ints = data.unpack("n" * (size / 2))
        if (id == "GNRE")
          @data_atoms[id] = mp4_genres[ints[0]]
        elsif (size >= 6)
          @data_atoms[id] = [ints[1], ints[2]]
        else
          @data_atoms[id] = ints[1]
        end
      elsif (type == 1)
        if (id == "GEN")
          return if @data_atoms["GNRE"] != nil
          id = "GNRE"
        elsif (id == "AART")
          return if @data_atoms["ART"] != nil
          id = "ART"
        elsif (id == "DAY")
          data = data[0..3]
          return if data == 0
        end
        
        @data_atoms[id] = data
      elsif (type == 21)
        if (size == 1)
          @data_atoms[id] = data.unpack("C")[0]
        elsif (size == 2)
          @data_atoms[id] = data.unpack("n")[0]
        elsif (size == 4)
          @data_atoms[id] = data.unpack("N")[0]
        elsif (size == 8)
          hi, low = data.unpack("NN")
          @data_atoms[id] = hi * (2 ** 32) + low
        else
          @data_atoms[id] = data
        end
      elsif (type == 13 || type == 14)
        @data_atoms[id] = data
      end
    end
    
    # Utility method; read bytes bytes from io_stream or raise message message
    def read_or_raise(io_stream, bytes, message)
      buffer = io_stream.read(bytes)
      if (buffer.length != bytes)
        raise message
      end
      buffer
    end
end
